feat: add multi-server chatbot client example#1691
feat: add multi-server chatbot client example#1691travisbreaks wants to merge 4 commits intomodelcontextprotocol:mainfrom
Conversation
Adds a new standalone example that connects to multiple MCP servers simultaneously, aggregates their tools, and routes tool calls to the correct server. This is the TypeScript equivalent of the Python SDK's simple-chatbot example, addressing issue modelcontextprotocol#740. The example follows the client-quickstart pattern: minimal dependencies (Anthropic SDK + MCP client), standalone package, simple CLI interface. It uses a JSON config file (same format as Claude Desktop) to define servers and includes an agentic loop for multi-step tool use. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
@claude review |
There was a problem hiding this comment.
LGTM — straightforward new example following the existing client-quickstart pattern with only minor nits.
Extended reasoning...
Overview
This PR adds a new examples/client-multi-server/ directory containing a ~170-line CLI chatbot that connects to multiple MCP servers, aggregates tools, and routes tool calls. It also adds the expected config entries (.changeset/config.json ignore list, .prettierignore). No library code is modified — only new example files and the lockfile.
Security risks
None. This is a standalone CLI example that reads a local JSON config and calls the Anthropic API. No auth, crypto, or permissions code is involved. The env passthrough in transport config mirrors the existing quickstart pattern.
Level of scrutiny
Low scrutiny is appropriate. This is self-contained example code that closely mirrors the existing client-quickstart example in structure, dependencies, and patterns. It does not affect any published packages (added to changeset ignore list). The examples/ directory is not covered by CODEOWNERS.
Other factors
All three bug reports are nits on example code with no runtime impact: (1) a type guard narrowing to the wrong SDK type (works due to structural compatibility), (2) no warning on tool name collisions across servers, and (3) a theoretical client leak if listTools fails after connect. These are worth noting as inline comments for the author to consider but do not block merging. The PR fixes an open issue (#740) and the author confirms all tests, typecheck, and lint pass.
felixweinberger
left a comment
There was a problem hiding this comment.
Thanks for this — the mcpServers config parsing and agentic loop are solid. A few changes before merge:
1. Prefix tool names with server name
The flat Map<toolName, serverName> means if two servers both expose a tool with the same name, the last one silently overwrites the first. Prefix tool names to avoid this:
const prefixedName = `${serverName}__${tool.name}`;
this.toolToServer.set(prefixedName, { serverName, originalName: tool.name });
this.tools.push({ name: prefixedName, ... });Then strip the prefix before calling the server.
2. Default servers.json should have 2+ servers
A multi-server example with one server in the default config doesn't demonstrate the routing. Add @modelcontextprotocol/server-time as a second entry — it works via npx with no extra setup.
3. Fix the content type predicate
filter((c): c is Anthropic.TextBlock => c.type === 'text') — MCP CallToolResult content blocks aren't Anthropic.TextBlock. Use TextContent from @modelcontextprotocol/core.
- Prefix tool names with serverName__ to prevent silent overwrites when multiple servers expose identically-named tools. Original names are preserved and used for the actual callTool requests. - Fix incorrect type guard that narrowed MCP ContentBlock to Anthropic.TextBlock (wrong type). Now uses plain .filter(). - Move this.servers.set() before listTools() so cleanup can still close the client if tool discovery throws. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@claude review |
| if (block.type === 'text') { | ||
| finalText.push(block.text); | ||
| } else if (block.type === 'tool_use') { | ||
| const mapping = this.toolToServer.get(block.name); | ||
| if (!mapping) { | ||
| toolResults.push({ | ||
| type: 'tool_result', | ||
| tool_use_id: block.id, | ||
| content: `Error: unknown tool "${block.name}"`, | ||
| is_error: true, | ||
| }); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
🔴 When client.callTool() returns a result with isError: true (an in-band tool error), the code does not set is_error: true on the ToolResultBlockParam sent back to Claude — only thrown exceptions set this flag. As a result, Claude receives the error message as a successful tool result and cannot self-correct, which is the exact failure mode the MCP spec warns against. Fix by adding result.isError propagation to the toolResults.push() call.
Extended reasoning...
What the bug is
In processQuery(), the try block calls client.callTool() and constructs a ToolResultBlockParam from the result. The catch block correctly sets is_error: true when an exception is thrown (e.g., transport/protocol failure). However, the MCP protocol also allows servers to report tool-level failures in-band by returning { isError: true, content: [...] } without throwing. The code never checks result.isError and therefore never sets is_error: true for in-band errors.
The specific code path
Lines 120-129 of examples/client-multi-server/src/index.ts:
const resultText = result.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("
");
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: resultText, // no is_error: true even when result.isError === true
});
The is_error field is only set inside the catch block, never in the success path.
Why existing code does not prevent it
The outer try/catch catches exceptions (network errors, JSON parse failures, etc.) but does not observe the return value of callTool(). The result.isError field is a separate, in-band signal that sits in the return value and is simply never read.
Impact
The MCP spec (packages/core/src/types/spec.types.ts:1563) explicitly states: "Any errors that originate from the tool SHOULD be reported inside the result object, with isError set to true, not as an MCP protocol-level error response. Otherwise, the LLM would not be able to see that an error occurred and self-correct." By omitting is_error: true, Claude receives the error text as a normal successful result and may continue reasoning incorrectly (e.g., treating "File not found" as actual file content). The example also teaches incorrect patterns to developers who copy it.
Step-by-step proof
- An MCP server returns { isError: true, content: [{ type: "text", text: "File not found" }] } from a callTool request.
- The code enters the try block (no exception is thrown).
- result.content.filter/map/join produces "File not found".
- toolResults.push({ type: "tool_result", tool_use_id: block.id, content: "File not found" }) -- no is_error: true.
- Claude receives a tool_result block with content: "File not found" and no error flag; it interprets this as successful output and continues reasoning incorrectly.
How to fix it
Propagate result.isError to the block param:
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: resultText,
...(result.isError && { is_error: true }),
});
| const configPath = process.argv[2] ?? 'servers.json'; | ||
|
|
||
| const mcpClient = new MultiServerClient(); | ||
| try { | ||
| await mcpClient.connectToServers(configPath); | ||
|
|
||
| const apiKey = process.env.ANTHROPIC_API_KEY; | ||
| if (!apiKey) { | ||
| console.log( | ||
| '\nNo ANTHROPIC_API_KEY found. To chat with these tools via Claude, set your API key:' | ||
| + '\n export ANTHROPIC_API_KEY=your-api-key-here' | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| await mcpClient.chatLoop(); | ||
| } catch (e) { | ||
| console.error('Error:', e); |
There was a problem hiding this comment.
🟡 In main(), process.exit(1) in the catch block terminates the Node.js process synchronously before the finally block runs, so mcpClient.cleanup() is never called on error. The fix is to replace process.exit(1) with process.exitCode = 1, allowing control to fall through to the finally block which then calls cleanup() before process.exit(0).
Extended reasoning...
What the bug is
In main() (lines 193-210 of examples/client-multi-server/src/index.ts), the error handling uses process.exit(1) inside the catch block. In Node.js, process.exit() terminates the process synchronously and immediately — it halts the event loop without unwinding the JavaScript call stack. This means finally blocks do not execute after process.exit() is called.
The specific code path
try {
await mcpClient.connectToServers(configPath);
await mcpClient.chatLoop();
} catch (e) {
console.error('Error:', e);
process.exit(1); // process terminates HERE — finally never runs
} finally {
await mcpClient.cleanup(); // NEVER REACHED on error
process.exit(0);
}When connectToServers() or chatLoop() throws, the catch block calls process.exit(1), terminating the process before the finally block can execute mcpClient.cleanup().
Why existing code does not prevent it
This is a fundamental Node.js behavior — try/finally only ensures the finally block runs when control leaves the try block via normal flow, exceptions, or return/break. It does not protect against process.exit(), which terminates the process unconditionally without stack unwinding.
Step-by-step proof
connectToServers()successfully connects to servers A and B, then server C fails and throws.- The
catchblock runs:console.error('Error:', e)logs the error. process.exit(1)is called — the Node.js event loop halts immediately.- The
finallyblock never executes.mcpClient.cleanup()is never called. - The stdio transports for servers A and B are not gracefully closed via the MCP protocol. While the OS will eventually clean up file descriptors and child processes will receive EOF/SIGPIPE on stdin, there is no graceful MCP shutdown.
Impact
For this specific CLI example, the practical impact is limited — the OS reclaims resources on process exit, and child processes typically exit once their stdin pipe closes. However, this is example code that developers will copy into production applications. Using process.exit() inside a catch alongside a finally is a pedagogically incorrect pattern that defeats the purpose of finally and could cause real resource leaks in production services derived from this example.
How to fix
Replace process.exit(1) in the catch block with process.exitCode = 1. This sets the exit code without terminating immediately, allowing control to fall through to the finally block:
} catch (e) {
console.error('Error:', e);
process.exitCode = 1; // set exit code, do not terminate yet
} finally {
await mcpClient.cleanup(); // now runs on both success and error
process.exit(); // exits with the code set above
}| rl.close(); | ||
| } | ||
| } | ||
|
|
||
| async cleanup() { | ||
| for (const [name, client] of this.servers) { | ||
| console.log(`Disconnecting from ${name}...`); | ||
| await client.close(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async function main() { |
There was a problem hiding this comment.
🟡 The ANTHROPIC_API_KEY check occurs after connectToServers() in main(), meaning a user who forgot to set the key will first wait for npm package downloads, server spawning, and full tool discovery before being told to set their key. Moving the API key check to before connectToServers() would fail fast with a clear message.
Extended reasoning...
What the bug is
In main(), connectToServers(configPath) is called unconditionally before the ANTHROPIC_API_KEY environment variable is read (lines 185 and 188 respectively in the final file). Only after all server connections and tool discovery complete does the code check for the key and print a friendly error message.
The specific code path
async function main() {
const configPath = process.argv[2] ?? servers.json;
const mcpClient = new MultiServerClient();
try {
await mcpClient.connectToServers(configPath); // spawns processes, downloads packages
const apiKey = process.env.ANTHROPIC_API_KEY; // checked only after
if (!apiKey) {
console.log(\nNo ANTHROPIC_API_KEY found...);
return; // wasted work
}
await mcpClient.chatLoop();
} ...
}Why existing code does not prevent it
There is no early validation of required prerequisites before entering the expensive setup phase. The API key check is deferred until after connectToServers() completes, which for the default servers.json triggers npx -y @modelcontextprotocol/server-everything — downloading and installing the npm package on first run before establishing the MCP connection and discovering all tools.
Addressing the refutation
The refutation argues this is intentional, allowing users to verify MCP server connectivity without Anthropic credentials. However, this argument does not hold up: the code immediately returns after printing the "no API key" message without offering any tool exploration functionality. If the intent were to support a "browse tools without chatting" mode, there would be a listTools() display or similar. The message itself says "To chat with these tools via Claude" — signalling that chatting is the only intended purpose. The README also lists the API key as a prerequisite before running.
Step-by-step proof
- User clones the repo and runs
npx tsx src/index.tswithout settingANTHROPIC_API_KEY. connectToServers()starts. It readsservers.jsonand finds theeverythingentry usingnpx -y @modelcontextprotocol/server-everything.- On first run,
npxdownloads and installs@modelcontextprotocol/server-everything(~seconds to a minute depending on network). - The server starts via stdio, MCP handshake completes,
listTools()is called and all tools are discovered. connectToServers()returns. Only now isprocess.env.ANTHROPIC_API_KEYread.- The key is missing. A friendly message is printed and the function returns.
cleanup()infinallyiterates all connected servers and callsclient.close()on each — closing connections that were opened purely to be immediately thrown away.- The user waited significant time (npm download + install + server boot) just to learn they need to set an env var.
How to fix
Move the API key check to the top of main(), before connectToServers():
async function main() {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
console.error(Error: ANTHROPIC_API_KEY is not set.);
console.error( export ANTHROPIC_API_KEY=your-api-key-here);
process.exit(1);
}
// ... rest of setup
}This is a one-line change that provides immediate feedback without touching any functionality.
|
|
||
| // Discover tools from this server | ||
| const toolsResult = await client.listTools(); | ||
| for (const tool of toolsResult.tools) { | ||
| const prefixedName = `${name}__${tool.name}`; | ||
| if (this.toolToServer.has(prefixedName)) { | ||
| console.warn( | ||
| ` Warning: tool "${tool.name}" from server "${name}" collides with an existing tool.` | ||
| ); |
There was a problem hiding this comment.
🟡 The collision check on line 47 is effectively dead code: since prefixedName is ${name}__${tool.name} and name is the current server name (unique across the outer loop), a collision can only occur if the same server reports duplicate tool names in a single listTools() response, which is an MCP protocol violation. The warning message 'collides with an existing tool' is also misleading — users would interpret it as a cross-server collision, but prefixing already makes cross-server collisions impossible by design.
Extended reasoning...
What the bug is
The collision detection block on lines 47-51 checks whether toolToServer already contains prefixedName, where prefixedName = name + "__" + tool.name. The intent, based on the warning message, is to catch situations where two different servers expose a tool with the same name. However, because the server name is baked into the prefix, tools from different servers can never share the same prefixed key.
The specific code path
In the tool registration loop, for each server name and each tool from that server, the code computes prefixedName as the concatenation of the server name, a double-underscore separator, and the tool name. It then checks if this key already exists in the map. Since JSON object keys are guaranteed unique, name is distinct for every iteration of the outer loop, so this prefixed key is unique per server+tool pair by construction.
Why existing code does not prevent the issue
The prefixing scheme was added (correctly) to solve cross-server name collision, and it does solve it. But the collision-detection if block was left in place without being updated to reflect the new invariant. Nobody removed or rewrote it to match the new key format, so it now tests for an impossible condition.
Impact
There is no incorrect runtime behavior — the prefixing correctly routes all tool calls. The impact is purely one of code clarity and misleading diagnostics. A developer reading this example could incorrectly believe the if block guards against cross-server collisions. If a broken MCP server ever did return duplicate tool names, the warning would confusingly say "collides with an existing tool" with no indication that it is a same-server duplicate.
How to fix it
Two options: (1) Remove the check entirely — cross-server collisions are impossible with the prefix scheme, and same-server duplicates are an MCP protocol violation that does not need a warning in example code. (2) Update the warning to clarify the actual scenario, e.g. 'Warning: server X reported duplicate tool name Y — later entry overwrites earlier.'
Step-by-step proof
Suppose two servers 'weather' and 'news' both expose a tool named 'fetch':
- Iteration 1 (name = 'weather'): prefixedName = 'weather__fetch'. Map is empty, no collision. Map now contains 'weather__fetch'.
- Iteration 2 (name = 'news'): prefixedName = 'news__fetch'. Map contains 'weather__fetch' only. has('news__fetch') is false — no collision fires.
The warning can never fire for this cross-server case. The only way it could fire is if the same server returned 'fetch' twice in a single listTools() response, which is an MCP protocol violation that should never happen in practice.
felixweinberger
left a comment
There was a problem hiding this comment.
Thanks for the iterations - apologies, my previous review was posted prematurely.
It's probably valid to call out some multi-server example. but this mostly duplicates the quickstart and pins another anthropic SDK version. The novel part is the tool-name prefixing and server routing.
I think this could be slimmed down to a minimal routing example, something that spawns 2 in-repo servers (e.g. server-quickstart + mcpServerOutputSchema) and just shows the routing between them rather than a full chatbot client implementation. That would also drop the Anthropic API key dep so it runs without it.
Replace the full chatbot implementation with a minimal script that: - Spawns two in-repo servers (server-quickstart + mcpServerOutputSchema) - Connects a Client to each via StdioClientTransport - Discovers tools and builds a prefixed routing table - Calls one tool on each server to demonstrate routing Removes the Anthropic SDK dependency and servers.json config file. No API key required to run. Co-Authored-By: Tadao <tadao@travisfixes.com>
Summary
Adds a
client-multi-serverexample that connects to multiple MCP servers simultaneously and routes tool calls to the correct server. TypeScript equivalent of the Python SDK's simple-chatbot example.Fixes #740
What it does
servers.json(same format as Claude Desktop'smcpServersblock)Implementation
client-quickstartexample pattern (package structure, tsconfig, dependencies)servers.jsonpointing toserver-everythingfor easy testingTest plan